主题
容器与 Docker
把 Go 程序编成 Linux 可执行文件,再放进镜像,只解决了部署的一半。另一半是:这个进程能看见哪些文件、能用多少资源、以谁的身份运行,以及出了问题怎样检查。本篇沿着“构建一个镜像,再启动一个进程”解释 Docker,最后给出可以自行复现的多阶段构建配置。
本次实际运行环境为 Go 1.27.2、Linux/amd64。完成了 Go 测试、race、vet、静态二进制检查及分层删除的教学实验;环境没有 Docker daemon,所以没有声称镜像已经构建、运行,或测出镜像大小。Docker、BuildKit 的配置以 2026-10-13 查阅的官方文档为依据,部署前仍须核对所用版本。
先分清四个对象
| 对象 | 包含什么、负责什么 | 常用动作 | 容易混淆的地方 |
|---|---|---|---|
| Dockerfile | 构建步骤及镜像默认配置 | docker build / docker buildx build | 它是配方,执行结果才是镜像 |
| 镜像 image | 文件系统层、入口、环境变量等配置 | pull、inspect、push | tag 可以移动;digest 标识内容 |
| 容器 container | 由镜像启动的进程,加上运行时配置与可写层 | run、logs、stop、inspect | 删除容器会丢掉未持久化的可写层内容 |
| Registry | 保存、分发镜像内容 | 推送或按 digest 拉取 | 仓库登录权限不等于容器运行权限 |
同一个镜像可以启动多个容器,每个容器都有自己的进程状态和可写层。进程退出以后镜像不会改变;容器中的配置文件被人临时修改,也不会自动变成下次部署使用的新镜像。Docker 对象与架构
正在绘制图示…
查看图源代码
flowchart LR
S[源码与构建输入] --> B[BuildKit 执行 Dockerfile]
B --> I[镜像 manifest、配置、只读层]
I --> R[Registry 按内容分发]
R --> C1[容器 A:进程与可写层]
R --> C2[容器 B:进程与可写层]
C1 --> K[宿主 Linux 内核]
C2 --> K
C1 --> V[独立持久卷]
图中的两只容器共享的是宿主内核和可复用的镜像内容,不共享一个 Go 堆。持久卷单独画出来,是因为数据库文件、用户上传文件不能依赖一个随容器删除而消失的可写层。
一个普通进程,怎样获得“自己的环境”
Linux 容器不是缩小的虚拟机。虚拟机通常有自己的客户机内核;Linux 容器仍由宿主 Linux 内核执行系统调用。Docker Desktop 可以先在虚拟机里运行 Linux,再在这个 Linux 里运行容器,这不改变容器本身的进程模型。
| 机制 | 控制的事情 | 不负责的事情 |
|---|---|---|
| PID namespace | 进程看到的 PID 集合及编号 | 不限制 CPU 或内存 |
| Mount namespace | 进程看到哪些挂载点 | 不自动让文件只读 |
| Network namespace | 接口、路由、端口等网络视图 | 不等于默认拒绝全部网络流量 |
| User namespace | 容器内外 UID/GID 的映射 | 不代替所有访问控制 |
| cgroup | 对进程组计量、限制、分配资源 | 不隐藏进程或文件 |
| capabilities、seccomp、LSM | 缩小特权、系统调用及安全访问范围 | 不能消除共享内核的风险 |
namespace 解决“看见什么”;cgroup 解决“用多少、如何分配”。例如 cgroup v2 的 cpu.max 用 quota 和 period 限制平均 CPU 时间,memory.max 是内存控制边界。CPU 超过配额可能被节流,内存超过约束可能触发回收与 OOM;两者的失败形式不同。Docker 底层技术、内核 cgroup v2 文档
USER 65532:65532 让应用不以容器内 root 身份运行。rootless Docker 则进一步让 daemon 和容器在非 root 用户及 user namespace 中运行,两者不是同一个设置。rootless 可以减少 daemon 漏洞的影响,但仍受内核、网络实现、资源控制配置等条件约束。将 Docker socket 挂进容器、启用 privileged 或任意挂载宿主目录,会显著扩大权限;一个 nonroot 标签不能抵消这些授权。rootless、运行权限
镜像分层:删除文件为什么不等于删除旧内容
镜像可以由多层文件变更组成。启动时,存储实现把这些只读内容和容器的可写层组成一个可见文件系统。以经典 overlay2 为例,读文件时上层覆盖下层;修改下层文件时可能先发生 copy-up,再修改上层副本。第一次修改一个大文件的成本,因此可能大于只修改几个字节的直觉。当前 Docker 还可以使用 containerd image store 的 snapshotter,不能把 overlay2 的目录布局当成所有安装的共同事实。OverlayFS 与 overlay2
正在绘制图示…
查看图源代码
flowchart TB
U[容器可写层:修改 config,隐藏 token] --> M[合并后的文件视图]
L2[镜像第 2 层:应用二进制] --> M
L1[镜像第 1 层:基础文件与 token] --> M
M --> P[进程只看到合并结果]
L1 --> E[读取旧层仍可得到 token]
OCI 的层格式用 whiteout 表示下层文件被删除。例如 .wh.token.txt 表示在应用这一层时隐藏 token.txt,并不改写旧层的 tar 内容。OCI Image Spec v1.1.1
下面是本文实际运行的极小模型的关键动作,完整 Python 在文末。它只实现根目录普通文件的 whiteout,未实现全部 OCI 层语义,更不是 Docker 或 OverlayFS 测试。
python
if h.name.startswith(".wh."):
view.pop(h.name[4:], None)
else:
view[h.name] = t.extractfile(h).read()text
merged view contains token: False
old layer still contains bytes: True这解释了为什么不能 COPY 密钥,再在后面的 RUN 删除。构建凭据应通过 BuildKit secret mount 或 SSH mount 临时提供,且构建命令不能把凭据抄进产物、日志或缓存。ARG TOKEN、ENV TOKEN 也不适合承载秘密。更换 secret 内容本身不会自动令 BuildKit 的构建缓存失效,需要时用一个不含秘密的缓存版本参数显式失效。构建秘密、secret 缓存规则
多阶段构建:最终运行时不需要编译器
Go 的构建环境需要工具链、模块缓存,有时还需要 C 编译器;运行一个已编译的纯 Go 程序不需要这些工具。多阶段构建允许第一阶段做编译,第二阶段只取必要文件。多阶段构建
dockerfile
ARG BUILDER_IMAGE
FROM ${BUILDER_IMAGE} AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/api .
FROM scratch
COPY --from=build /out/api /api
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
USER 65532:65532
ENTRYPOINT ["/api"]这里不填一个看起来真实、实际上没有核验过的 digest。BUILDER_IMAGE 必须由读者提供:一个含 Go 1.27.2、带上述 CA 路径的 Debian 系官方 Go 构建镜像,并固定到查验过的 sha256 摘要。本次没有 Registry 拉取,因此也没有确认某个具体 tag 存在。
COPY --from=build 明确了产物来源;最终阶段没有继承编译器。JSON 数组形式的 ENTRYPOINT 直接执行二进制,不依赖 shell。shell 形式还会引入 PID 1、信号转发的问题,而 scratch 本来就没有 /bin/sh。Dockerfile ENTRYPOINT
本地先检查这个二进制是否满足预期,比等容器启动时报错更容易定位问题:
sh
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o api .
./api --selftest
readelf -l api本次 --selftest 的实际输出和脚本判断为:
text
health=200 body="ok\n" timezone=Asia/Shanghai
ELF interpreter: absent最后一行是验证脚本检查 ELF 没有 INTERP 段后打印的结果,不是 readelf 原始输出。它证明这次构建没有声明动态加载器;它不能证明所有 CGO_ENABLED=0 项目都能编译,也不能证明程序不需要外部数据文件。本文 HTTP handler 是用 httptest 运行的,没有冒充容器网络实测。
scratch、distroless 和普通发行版怎么选
scratch 是空基础,几乎所有文件都要自己准备;distroless 提供经过筛选的运行时文件,通常没有 shell 和包管理器;普通发行版镜像保留较完整的用户空间。distroless 有 static、base、cc 等不同系列,不能把一个系列的能力套到另一个系列上。distroless 官方说明
| 运行需求 | scratch | 合适系列的 distroless | 普通发行版精简镜像 |
|---|---|---|---|
| 纯 Go 静态程序 | 可用,自己准备数据 | 通常较省准备工作 | 可用,额外文件较多 |
| 动态 libc / cgo 依赖 | 不能只复制可执行文件 | 必须选匹配 ABI 的系列 | 需匹配 libc 与动态库 |
| HTTPS 出站 | 自备受信 CA | 核对具体镜像的 CA 内容 | 也应核对 CA 是否安装、更新 |
| IANA 时区 | 拷贝 zoneinfo 或嵌入 tzdata | 核对所选镜像内容 | 通常可安装数据包 |
| 现场调试 | 无 shell、curl、ps | 默认无 shell;另有调试变体 | 常见工具更容易取得 |
| 更新责任 | 自己追踪二进制与拷入文件 | 追踪基础镜像与程序 | 追踪更多用户空间包 |
本文通过空白导入 time/tzdata 把时区数据库嵌入二进制,因此 Asia/Shanghai 不依赖运行镜像中的 zoneinfo。代价是二进制增大,而且时区规则更新需要重新构建。CA 采用从构建阶段复制的方式;这也使 CA 更新成为构建与发布的责任。程序如果要写临时文件,还须提供可写目录或 tmpfs,不能指望空镜像天然有完整的 /tmp 权限安排。Go tzdata、Go x509 系统根证书
生产排错应提前准备:结构化 stdout/stderr 日志、指标、受保护的诊断入口,以及受控的调试镜像或临时调试容器。镜像小通常减少分发数据与无关软件,但“小”本身不证明安全,也不能代替补丁、身份、权限和网络隔离。
缓存应该节省什么,不能偷偷改变什么
构建缓存命中取决于指令及其输入。对 COPY、ADD 等,输入文件的内容与有关元数据参与判断;仅修改 mtime 不一定令缓存失效。RUN 的远端下载结果不会因为网站上的文件变了就自动重新获取。缓存失效规则
实际项目有第三方模块时,常把 COPY go.mod go.sum ./ 和 RUN go mod download 放在复制业务源码之前:改业务代码不会迫使相同依赖重新下载。本文没有外部模块,也没有 go.sum,所以没有照抄一个会因不存在的 go.sum 而失败的指令。BuildKit cache mount 可以缓存 /go/pkg/mod 与 Go 构建缓存,但缓存是可丢弃的加速数据;构建正确性不能依赖“上一台机器正好留下了什么”。
| 变化 | 期望行为 | 要检查的风险 |
|---|---|---|
| 只改业务源码 | 复用依赖步骤,重新编译 | 是否过早 COPY . . 扩大失效范围 |
| 改模块版本 | 重新解析相应依赖 | go.mod/go.sum 是否一起审查 |
| 更新基础镜像 | 新 digest 进入构建 | 固定 tag 仍可能指向新内容 |
| 轮换构建 secret | 凭据替换,必要步骤显式失效 | secret 内容变化不会自动破缓存 |
| 安全补丁发布 | 受控更新后重新测试、发布 | 固定 digest 也意味着不会自动得到补丁 |
部署时固定 digest,可以回答“这次到底运行了哪些字节”。需要多平台镜像时还要区分 image index 摘要与某个平台 manifest 摘要,记录目标平台。固定版本是可追踪性的起点,配合持续更新才能兼顾安全;永久冻结不更新同样有风险。固定基础镜像
验证记录与尚未验证的部分
2026-10-13,在 Go 1.27.2、Linux/amd64 上,go test ./...、go test -race ./...、go vet ./... 通过。测试覆盖 health 路径和不存在路径,局部输出及 ELF 检查见上文。Python 模型确认 whiteout 隐藏不等于原层字节清除。
没有运行 Docker build/run,没有测量镜像大小、缓存命中耗时、容器隔离或 cgroup 限制,也没有在 scratch 内验证 HTTPS。文末 Dockerfile 是待目标环境验证的完整配置,需先填写真实构建镜像摘要。当前 HTTP 示例的退出只展示基础 Shutdown;负载均衡传播等待、业务队列、WebSocket 等退出责任见《Go 在容器中》。
依据
- Docker 对象、架构与 namespace
- Linux cgroup v2:CPU 与内存控制器。
- Rootless、运行权限
- OverlayFS driver、OCI 层规范 v1.1.1
- 多阶段构建、ENTRYPOINT
- 构建 secrets、缓存失效、构建最佳实践
- distroless:镜像系列、非 root 与 debug 变体。
- time/tzdata、x509.SystemCertPool。Go 实测基于官方 Go 1.27.2 Linux/amd64 工具链。
完整复现文件
在同一目录保存以下文件。本地验证需要 Go 1.27.2、Python 3、GNU readelf;运行 sh verify.sh。Dockerfile 未执行,构建前需在自己的授权测试环境提供已核验的 BUILDER_IMAGE,然后运行 docker build --build-arg BUILDER_IMAGE="$BUILDER_IMAGE" -t containerdemo:local .。这条命令会下载镜像并在本机创建构建产物,本文未代为执行。
go.mod
go
module example.com/containerdemo
go 1.27.0main.go
代码块 · 59 行 · 点击收起 / 展开
go
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/signal"
"syscall"
"time"
_ "time/tzdata"
)
func handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })
return mux
}
func run() error {
if len(os.Args) == 2 && os.Args[1] == "--selftest" {
w := httptest.NewRecorder()
handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return err
}
fmt.Printf("health=%d body=%q timezone=%s\n", w.Code, w.Body.String(), loc)
return nil
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{Addr: ":8080", Handler: handler(), ReadHeaderTimeout: 5 * time.Second}
result := make(chan error, 1)
go func() { result <- srv.ListenAndServe() }()
select {
case err := <-result:
return err
case <-ctx.Done():
}
stop()
drain, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(drain); err != nil {
_ = srv.Close()
return err
}
if err := <-result; !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}main_test.go
go
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealth(t *testing.T) {
for _, tc := range []struct {
path string
want int
}{{"/healthz", 200}, {"/missing", 404}} {
w := httptest.NewRecorder()
handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil))
if w.Code != tc.want {
t.Fatalf("%s: got %d want %d", tc.path, w.Code, tc.want)
}
}
}Dockerfile
dockerfile
# Supply a reviewed Debian-based official golang image pinned by digest.
ARG BUILDER_IMAGE
FROM ${BUILDER_IMAGE} AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/api .
FROM scratch
COPY --from=build /out/api /api
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/api"].dockerignore
text
.git
.env*
*.pem
*.key
*.tar
api
*_test.go
layer_demo.py
verify.sh
Dockerfile.badDockerfile.bad
dockerfile
# Negative teaching fixture; DO NOT build with real secrets.
FROM scratch
COPY fake-token.txt /token.txt
# A later deletion layer would hide /token.txt, not erase the earlier layer.
# RUN rm /token.txt also fails on scratch because it has no shell.layer_demo.py
python
"""A tiny OCI whiteout teaching model, not a Docker/OverlayFS implementation."""
import io
import tarfile
def layer(name, data):
out = io.BytesIO()
with tarfile.open(fileobj=out, mode="w") as t:
h = tarfile.TarInfo(name)
h.size = len(data)
t.addfile(h, io.BytesIO(data))
return out.getvalue()
first = layer("token.txt", b"FAKE_TOKEN_FOR_DEMO")
second = layer(".wh.token.txt", b"")
view = {}
for blob in (first, second):
with tarfile.open(fileobj=io.BytesIO(blob)) as t:
for h in t:
if h.name.startswith(".wh."):
view.pop(h.name[4:], None)
else:
view[h.name] = t.extractfile(h).read()
assert "token.txt" not in view
assert b"FAKE_TOKEN_FOR_DEMO" in first
print("merged view contains token:", "token.txt" in view)
print("old layer still contains bytes:", b"FAKE_TOKEN_FOR_DEMO" in first)verify.sh
sh
#!/bin/sh
set -eu
cd "$(dirname "$0")"
go version
go test ./...
go test -race ./...
go vet ./...
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o api .
./api --selftest
if readelf -l api | grep -q INTERP; then echo 'unexpected ELF interpreter'; exit 1; fi
echo 'ELF interpreter: absent'
python3 layer_demo.pyfake-token.txt
text
FAKE_TOKEN_FOR_DEMO